--- 模块功能：HTTP客户端
-- @module http
-- @author 稀饭放姜
-- @license MIT
-- @copyright OpenLuat.com
-- @release 2020.03.23
require "socket"
require "utils"
module(..., package.seeall)
local isLog = true
-- multipart/form-data 类型提交表单
local boundary = {
    "ZnGpDtePMx0KrHh_G0X99Yef0r8JZsRJSXC", -- boundary
    "--ZnGpDtePMx0KrHh_G0X99Yef0r8JZsRJSXC\r\n", -- boundary start
    'Content-Disposition: form-data; name="file"; filename="', --  表单提交必须字段
    "\r\n--ZnGpDtePMx0KrHh_G0X99Yef0r8JZsRJSXC--\r\n", -- boundary end
}
-- 内置提交内容的类型
local Content_type = {
    "application/x-www-form-urlencoded",
    "application/json",
    "application/octet-stream",
    "multipart/form-data",
    "text/plain",
    "image/jpeg",
}
-- 内置默认request headers
local headers = {
    -- ["User-Agent"] = "Mozilla/5.0",
    -- ["Accept"] = "*/*",
    -- ["Accept-Language"] = "zh-CN,zh,cn",
    -- ["Content-Type"] = "application/x-www-form-urlencoded",
    }

--- HTTP客户端
-- @string method,提交方式"GET" or "POST"
-- @string url,HTTP请求超链接
-- @number timeout,超时时间
-- @param query,table类型，请求发送的查询字符串，通常为键值对表
-- @param data,table类型，正文提交的body,通常为键值对、json或文件对象类似的表
-- @number ctype,Content-Type的类型(可选1,2,3),默认1:"urlencode",2:"json",3:"octet-stream"
-- @string basic,HTTP客户端的authorization basic验证的"username:password"
-- @param headers,table类型,HTTP headers部分
-- @param cert,table类型，此参数可选，默认值为： nil，ssl连接需要的证书配置，只有ssl参数为true时，才参数才有意义，cert格式如下：
-- {
--  caCert = "ca.crt", --CA证书文件(Base64编码 X.509格式)，如果存在此参数，则表示客户端会对服务器的证书进行校验；不存在则不校验
--  clientCert = "client.crt", --客户端证书文件(Base64编码 X.509格式)，服务器对客户端的证书进行校验时会用到此参数
--  clientKey = "client.key", --客户端私钥文件(Base64编码 X.509格式) clientPassword = "123456", --客户端证书文件密码[可选]
--  }
-- @return string,table,string,正常返回response_code, response_header, response_body
-- @return string,string,错误返回 response_code, error_message
-- @usage local code, head, body = http.request(url, method, headers, body)
-- @usage local res, err  = http.request("http://wrong.url/ ")
function request(method, url, timeout, query, data, ctype, basic, head, cert, fnc)
    local response_header, response_code, sc = {}
    while true do
        local parsed = parse(url)
        local https = parsed.scheme == "https"
        local port = parsed.port or (https and 443 or 80)
        local file = type(data) == "string" and data:sub(1, 1) == "/" and io.exists(data) and "file"
        if not parsed.host then return "105", "ERR_NAME_NOT_RESOLVED" end
        if type(head) == "table" then
            for k, v in pairs(head) do headers[k] = v end
        elseif type(head) == "string" then
            for k, v in string.gmatch(head, "(.-):%s*(.-)\r\n") do headers[k] = v end
        end
        headers["Host"] = parsed.host .. ":" .. port
        headers["Content-Type"] = Content_type[ctype or 1]
        if headers["Content-Type"]:find("multipart/form-data", 1, true) then
            headers["Content-Type"] = "multipart/form-data" .. "; boundary=" .. boundary[1]
        end
        if type(query) == "table" then
            parsed.query = "?" .. table.urlEncode(query)
        elseif type(query) == "string" then
            parsed.query = "?" .. query:urlEncode()
        else
            parsed.query = parsed.query and "?" .. parsed.query or ""
        end
        -- 处理HTTP Basic Authorization 验证
        if parsed.userinfo then
            headers["Authorization"] = "Basic " .. crypto.base64_encode(parsed.userinfo, #parsed.userinfo)
        elseif type(basic) == "string" then
            headers["Authorization"] = "Basic " .. crypto.base64_encode(basic, #basic)
        end
        if type(data) == "string" then
            if file then
                file = "file"
                if headers["Content-Type"]:find("multipart/form-data", 1, true) then
                    boundary[3] = boundary[3] .. data:match("([^/]+)$") .. '"\r\n\r\n'
                    headers["Content-Length"] = #table.concat(boundary, "", 2) + io.fileSize(data)
                else
                    headers["Content-Length"] = io.fileSize(data)
                end
            else
                file = "string"
                headers["Content-Length"] = #data
            end
        elseif type(data) == "table" then
            if headers["Content-Type"] == "application/json" then
                file = "string"
                data = json.encode(data) or ""
                headers["Content-Length"] = #data
            elseif headers["Content-Type"] == "application/x-www-form-urlencoded" then
                file = "string"
                data = table.urlEncode(data)
                headers["Content-Length"] = #data
            elseif headers["Content-Type"]:find("multipart/form-data", 1, true) then
                file = "file"
                for k, v in pairs(data) do
                    if k == "path" then
                        boundary[3] = boundary[3] .. v:match("([^/]+)$") .. '"\r\n\r\n'
                    else
                        table.insert(boundary, 4, '\r\nContent-Disposition: form-data; name="' .. k .. '"\r\n\r\n' .. v:urlEncode())
                    end
                end
                data = data.path
                headers["Content-Length"] = #table.concat(boundary, "", 2) + (io.fileSize(data) or 0)
            else
                file = "table"
                if not headers["Content-Length"] then headers["Transfer-Encoding"] = "chunked" end
            end
        elseif type(data) == "function" then
            file = "fifo"
            headers["Transfer-Encoding"] = "chunked"
        else
            -- headers["Connection"] = "close"
            end
        if method:upper() == "GET" then headers["Content-Type"] = nil end
        -- 处理headers部分
        local str = ""
        for k, v in pairs(headers) do str = str .. k .. ": " .. v .. "\r\n" end
        -- 发送请求报文
        str = method:upper() .. " " .. (parsed.path or "/") .. parsed.query .. " HTTP/1.1\r\n" .. str .. "\r\n"
        while not socket.isReady() do sys.wait(1000) end
        sc = socket.new(https and "ssl" or "tcp", cert)
        sc:regFormat("*LL", "\r\n\r\n", 4)
        if not sc:connect(parsed.host, port) then
            sc:close()
            if isLog then log.error("http connect fail:", sc.host, sc.port) end
            return "502", "SOCKET_CONN_ERROR"
        end
        if not sc:send(str) then
            sc:close()
            return "412", "SOCKET_SEND_ERROR"
        end
        if isLog then log.info("发送的http报文:", str) end
        if file == "file" then
            if headers["Content-Type"]:find("multipart/form-data", 1, true) then
                if not sc:send(table.concat(boundary, "", 2, 3)) then
                    return "412", "SOCKET_SEND_ERROR"
                end
            end
            if isLog then log.info("http.boundary start:", table.concat(boundary, "", 2, 3)) end
            local f = io.open(data, "r")
            if f then
                while true do
                    local dat = f:read(1460)
                    if dat == nil then break end
                    if not sc:send(dat) then break end
                end
                f:close()
            end
            if headers["Content-Type"]:find("multipart/form-data", 1, true) then
                if not sc:send(table.concat(boundary, "", 4)) then
                    return "412", "SOCKET_SEND_ERROR"
                end
            end
            if isLog then log.info("http.boundary end:", table.concat(boundary, "", 4)) end
        elseif file == "table" then
            local len = headers["Content-Length"]
            if len then
                while len > 0 do
                    local stream = table.remove(data, 1)
                    if stream then
                        if not sc:send(stream) then break end
                        len = len - #stream
                    else
                        sys.wait(10)
                    end
                end
            else
                while #data > 0 do
                    local stream = table.remove(data, 1)
                    local size = string.format("%X\r\n", #stream)
                    if not sc:send(size .. stream .. "\r\n") then break end
                end
                sc:send(0 .. "\r\n")
            end
        elseif file == "fifo" then
            while true do
                local stream = data()
                if not stream then break end
                local len = string.format("%X\r\n", #stream)
                if not sc:send(len .. stream .. "\r\n") then break end
            end
            sc:send(0 .. "\r\n")
        elseif file == "string" then
            sc:send(data)
        end
        if data and not sc:send("\r\n") then
            sc:close()
            return "412", "SOCKET_SEND_ERROR"
        end
        ------------------------------------ 接收服务器返回消息部分 ------------------------------------
        local r, s = sc:recv(timeout, nil, "*LL")
        if not r then
            sc:close()
            return "408", "Request_Timeout"
        end
        -- 处理状态代码
        _, idx, response_code = s:find("%s(%d+)%s.-\r\n")
        if isLog then log.info("http.response:", s) end
        -- 处理headers代码
        for k, v in s:sub(idx + 1, -3):gmatch("(.-):%s*(.-)\r\n") do response_header[k] = v end
        if response_code:sub(1, 1) == "3" then --重定向
            sc:close()
            if not response_header["Location"] then
                return response_code, response_header
            end
            if response_header["Location"]:match("^([%w][%w%+%-%.]*)%://") then
                url = response_header["Location"]
            elseif response_header["Location"]:sub(1, 2) == "//" then
                url = parsed.scheme .. ":" .. response_header["Location"]
            elseif response_header["Location"]:sub(1, 1) == "/" then
                url = parsed.scheme .. "://" .. parsed.authority .. response_header["Location"]
            else
                if parsed.path then parsed.path = parsed.path:match(".*/") end
                response_header["Location"] = response_header["Location"]:gsub("^([./]*)", function() return "" end)
                url = parsed.scheme .. "://" .. parsed.authority .. (parsed.path or "/") .. response_header["Location"]
            end
        elseif response_code:sub(1, 1) == "2" then
            break
        else
            sc:close()
            return response_code, response_header
        end
    end
    local val, length, msg = 0, 0, {}
    if response_header["Transfer-Encoding"] == "chunked" then
        while true do
            local r, s = sc:recv(timeout, nil, "*L")
            if not r then break end
            val = tonumber(s:sub(1, -3), 16)
            if val == 0 then
                r, s = sc:recv(timeout, "*L", 2)
                break
            end
            r, s = sc:recv(timeout, nil, val + 2)
            if not r then break end
            length = length + #s - 2
            if type(fnc) == "function" then
                fnc(s:sub(1, -3), val)
            elseif type(fnc) == "table" then
                fnc[#fnc + 1] = s:sub(1, -3)
            elseif type(fnc) == "string" then
                io.writeFile(fnc, s:sub(1, -3), "a+b")
            else
                msg[#msg + 1] = s:sub(1, -3)
            end
        end
    elseif response_header["Content-Length"] then
        -- local len = tonumber(response_header["Content-Range"]:match("/(%d+)"))
        length = tonumber(response_header["Content-Length"])
        local splitChunk, sum = 32768, 0
        for i = 0, length, splitChunk do
            local r, s = sc:recv(timeout, nil, (length - i > splitChunk) and splitChunk or (length - i))
            if r then
                if type(fnc) == "function" then
                    sum = sum + #s
                    log.info("Http User cbFnc,#s,sum,total:", fnc(s, length), #s, sum, length)
                elseif type(fnc) == "table" then
                    fnc[#fnc + 1] = s
                elseif type(fnc) == "string" then
                    io.writeFile(fnc, s, "a+b")
                else
                    msg[#msg + 1] = s
                end
            else
                break
            end
        end
    else
        while true do
            local r, s = sc:recv(timeout, nil, "*L", 3000)
            if not r or s == "\r\n" then break end
            length = length + #s
            if type(fnc) == "function" then
                fnc(s, length)
            elseif type(fnc) == "table" then
                fnc[#fnc + 1] = s
            elseif type(fnc) == "string" then
                io.writeFile(fnc, s, "a+b")
            else
                msg[#msg + 1] = s
            end
        end
    end
    sc:close()
    if isLog then log.info("http.response.length:", length) end
    local gzip = response_header["Content-Encoding"] == "gzip"
    if length > 65535 then return response_code, response_header, msg end
    return response_code, response_header, gzip and zlib.decompress(table.concat(msg)) or table.concat(msg)
end

-----------------------------------------------------------------------------
--- 解析URL并返回一个 RFC 2396 标准的URL所有部分的表
-- RFC 2396标准URL:
-- <url> ::= <scheme>://<authority>/<path>;<params>?<query>#<fragment>
-- <authority> ::= <userinfo>@<host>:<port>
-- <userinfo> ::= <user>[:<password>]
-- <path> :: = {<segment>/}<segment>
-- 主意:{/<path>}中的"/" 属于<path>的一部分
-- @string url: uniform resource locator of request
-- @return table: RFC 2396 包含的字段：
--     scheme, authority, userinfo, user, password, host, port,
--     path, params, query, fragment
-----------------------------------------------------------------------------
function parse(url)
    local parsed = {}
    -- remove whitespace
    -- url = string.gsub(url, "%s", "")
    -- get fragment
    url = string.gsub(url, "#(.*)$", function(f)parsed.fragment = f; return "" end)
    -- get scheme
    url = string.gsub(url, "^([%w][%w%+%-%.]*)%://",
        function(s)parsed.scheme = s:lower(); return "" end)
    -- get authority
    url = string.gsub(url, "^([^/]*)", function(n)parsed.authority = n; return "" end)
    -- get query stringing
    url = string.gsub(url, "%?(.*)", function(q)parsed.query = q; return "" end)
    -- get params
    url = string.gsub(url, "%;(.*)", function(p)parsed.params = p; return "" end)
    -- get path
    parsed.path = url ~= "" and url or nil
    local authority = parsed.authority
    if not authority then return parsed end
    authority = string.gsub(authority, "^([^@]*)@",
        function(u)parsed.userinfo = u; return "" end)
    authority = string.gsub(authority, ":([^:]*)$",
        function(p)parsed.port = p; return "" end)
    if authority ~= "" then parsed.host = authority end
    local userinfo = parsed.userinfo
    if not userinfo then return parsed end
    userinfo = string.gsub(userinfo, ":([^:]*)$",
        function(p)parsed.password = p; return "" end)
    parsed.user = userinfo
    return parsed
end
--- HTTP 日志开关
-- @boolean[opt=nil] bool, true 显示日志,false 关闭日志
-- @return nil
-- @usage http.trace(true)
function trace(bool)
    isLog = bool
end
